第十四章 多线程编程
现代Linux的默认线程库为NPTL(Native POSIX Thred Library).
14.1 Linux线程概述
线程是程序中完成一个独立任务的完整执行序列, 即一个可调度的实体. 根据运行环境和调度者的身份, 线程可以分为内核线程和用户线程.
当进程的一个内核线程获得CPU的使用权时, 它就加载并运行一个用户线程. 可见内核线程相当于用户线程运行的容器.
一个进程可以拥有M个内核线程和N个用户线程, 其中M≤N; 并且一个系统的所有进程中, M和N的比值都是固定的.
按照M:N 的取值, 线程分为三种模式:
完全在用户空间实现: 无需内核支持, 内核甚至不知道这些线程的存在. 线程库负责管理所有执行线程, 比如线程的优先级, 时间片等. 优点是在创建和调度线程都无须内核的干预, 速度快. 缺点是只有一个进程, 对应一个内核线程(容器), 对于多处理器系统, 也无法在不同CPU上运行不同的线程. 另外, 线程的优先级只对该进程内的线程有效, 并不是全局的优先级, 在调度上有局限性.
完全由内核调度: 将创建, 调度任务都交给了内核, M:N=1:1. 优缺点与完全在用户空间实现恰恰相反.
双层调度: 结合了前两种方式, 因此综合了前两种方式的优点, 不但不会消耗过多的内核资源, 而且线程切换速度也较快, 同时可以充分发挥多处理器的优势.
14.2 创建线程和结束线程
pthread_create
1 |
|
- thread是新线程的标识符, 其实际上就是一个整型.
- attr用于设置新线程的属性, NULL表示默认.
- start_routine和arg分别指定新线程将运行的函数以及参数.
pthread_exit
线程函数在结束时最好调用pthread_exit, 以确保安全干净地退出
1 | void pthread_exit(void* retval); |
pthread_join
一个进程中地所有线程都可以调用pthread_join函数来回收其他线程, 即等待其他线程结束.
1 | int pthread_join(pthread_t thread, void** retval); |
- thread是目标线程的标识符, retval则是目标线程返回的退出信息.
- 该函数会一直阻塞, 直到目标线程结束. 失败则返回错误码
pthread_cancel
可以通过pthread_cancel异常终止一个线程;
1 | int pthread_cancel(pthread_t thread); |
接收到取消请求的目标线程也可以决定是否允许被取消以及如何取消
1 | int pthread_setcancelstate(int state, int* oldstate); |
state 有两个可选项:
PTHREAD_CANCEL_ENABLE,允许线程被取消。它是线程被创建时的默认取消状态。
PTHREAD_CANCEL_DISABLE,禁止线程被取消。这种情况下,如果一个线程收到取消请求,则它会将请求挂起,直到该线程允许被取消。
type 有两个可选项:
PTHREAD_CANCEL_ASYNCHRONOUS,线程随时都可以被取消。它将使得接收到取消请求的目标线程立即采取行动。
PTHREAD_CANCEL_DEFERRED,允许目标线程推迟行动,直到它调用了下面几个所谓的取消点函数中的一个:pthread_join、pthread_testcancel、pthread_cond_wait、pthread_cond timedwait、sem_wait和sigwait。.根据POSIX标准,其他可能阻塞的系统调用,比如read、wait,也可以成为取消点。不过为了安全起见,我们最好在可能会被取消的代码中调用pthread testcancel函数以设置取消点。
14.3 线程属性
- detachstate, 线程的脱离状态, 有PTHREAD_CREATE_JOINABLE和PTHREAD_CREATE_DETACH两种值, 默认为前者, 表示线程可回收. 后者表示该线程失去了与其他线程的同步, 在退出时会自行释放占用的资源.
- stackaddr和stacksize, 线程堆栈的起始地址和大小.
- guardsize, 保护区域的大小. 额外添加到堆栈尾部, 保护堆栈不被错误地覆盖的区域.
- schedparam, 线程调度参数, 表示线程的运行优先级.
- schedpolicy, 线程调度策略, 包含SCHED_FIFO, SCHED_RR和SCHED_OTHER三个选项. 其中SCHED_OTHER为默认值, SCHED_FIFO表示先进先出调度, SCHED_RR表示轮转算法调度, 后两种只能用于超级用户运行的进程.
- inheritsched, 是否继承调用线程的调度属性.
- scope, 线程间竞争CPU的范围, 即线程优先级的有限范围. 包含PTHREAD_SCOPE_SYSTEM和PTHREAD_SCOPE_PROCESS两个选项.
14.4 POSIX信号量
多线程也需要考虑同步问题, pthread_join也是一种简单的线程同步方式, 但是无法高效地实现复杂地同步需求.
在Linux上, 信号量有两组, 除了System V IPC信号量, 还有POSIX信号量.
- System V IPC信号量是一个或多个信号量的结构体, 较复杂, 常用于进程间同步; POSIX信号量是个非负整数, 较简单, 常用于线程间同步
- System V 信号量的引用头文件是 “<sys/sem.h>”,而POSIX 信号量的引用头文件是 “<semaphore.h>”
1 |
|
- sem_trywait是sem_wait的非阻塞版本, 当信号量值为0时, 返回-1, 并设置errno为EAGAIN.
14.5 互斥锁
互斥锁(也称互斥量)有点类似二进制信号量
1 |
|
互斥锁有两个常用属性: pshared和type
pshared有两个可选值:
- PTHREAD PROCESS SHARED。互斥锁可以被跨进程共享。
- PTHREAD PROCESS PRIVATE。互斥锁只能被和锁的初始化线程隶属于同一个进程的线程共享。
Linux支持4种type:
- PTHREAD_MUTEX_NORMAL,普通锁。这是互斥锁默认的类型。当一个线程对一个普通锁加锁以后,其余请求该锁的线程将形成一个等待队列,并在该锁解锁后按优先级获得它。这种锁类型保证了资源分配的公平性。但这种锁也很容易引发问题:一个线程如果对一个已经加锁的普通锁再次加锁,将引发死锁;对一个已经被其他线程加锁的普通锁解锁,或者对一个已经解锁的普通锁再次解锁,将导致不可预期的后果。
- PTHREAD_MUTEX_ERRORCHECK,检错锁。一个线程如果对一个已经加锁的检错锁再次加锁,则加锁操作返回EDEADLK。对一个已经被其他线程加锁的检错锁解锁,或者对一个已经解锁的检错锁再次解锁,则解锁操作返回EPERM。
- PTHREAD_MUTEX_RECURSIVE,嵌套锁。这种锁允许一个线程在释放锁之前多次对它加锁而不发生死锁。不过其他线程如果要获得这个锁,则当前锁的拥有者必须执行相应次数的解锁操作。对一个已经被其他线程加锁的嵌套锁解锁,或者对一个已经解锁的嵌套锁再次解锁,则解锁操作返回EPERM.。
- PTHREAD_MUTEX_DEFAULT,默认锁。一个线程如果对一个已经加锁的默认锁再次加锁,或者对一个已经被其他线程加锁的默认锁解锁,或者对一个已经解锁的默认锁再次解锁,将导致不可预期的后果。这种锁在实现的时候可能被映射为上面三种锁之一
14.6 条件变量
https://book.itheima.net/course/223/1277519158031949826/1277528625427521538
互斥锁用于同步线程对共享数据的访问, 那么条件变量则用于在线程之间同步共享数据的值. 条件变量提供了一种线程间的通知机制: 当某个共享数据达到某个值的时候, 唤醒等待这个共享数据的线程
1 |
|
- mutex参数是用于保护条件变量的互斥锁, 以确保pthread_cond_wait操作的原子性. 在调用pthread_cond_wait前必须确保互斥锁已经加锁, 否则将导致不可预期的结果. pthread_cond_wait在执行时, 首先把调用线程放入条件变量的等待队列中, 然后再把mutex解锁.
14.7 线程同步机制包装类
将信号量同步, 互斥锁, 条件变量三种同步机制分别封装为sem, locker, cond类
14.8 多线程环境
如果一个函数能被多个线程同时调用并且不发生竞态条件, 则称之为线程安全的, 或者称之为可重入函数. 在多线程程序种调用库函数, 一定要使用可重入版本(大部分可重入, 不可重入的提供了可重入版本, 只需在原函数名后加上”_r”
线程与进程
- 一个多线程程序的某个线程调用了fork函数, 新进程只含有单个线程(时调用fork的那个线程的完整复制). 另外, 子进程自动继承父进程中互斥锁的状态, 但这导致子进程不清楚从父进程继承来的互斥锁的具体状态(到底是被其他线程加锁的还是被父线程锁住的), 盲目操作可能导致死锁.
- pthread提供了一个专门的函数篇thread)atfork, 以确保fork调用后父进程和子进程都拥有一个清楚的锁状态.
1 | int pthread_atfork(void (*prepare)(void), void (*parent)(void), void (*child)(void)); |
该函数将建立3个fork句柄来帮助我们清理互斥锁的状态。
prepare句柄将在fork调用创建出子进程之前被执行。它可以用来锁住所有父进程中的互斥锁。
parent句柄则是fork调用创建出子进程之后,而fok返回之前,在父进程中被执行。它的作用是释放所有在prepare句柄中被锁住的互斥锁。
child句柄是fork返回之前,在子进程中被执行。和parent句柄一样,child句柄也是用于释放所有在prepare句柄中被锁住的互斥锁。
线程和信号
每个线程都可以独立地设置信号掩码, 在多线程环境下, 应该使用pthread版本地sigprocmask函数
1 | int pthread_sigmask(int how, const sigset_t* newmask, sigset_t* oldmask); |
由于进程中的所有线程共享该进程的信号,所以线程库将根据线程掩码决定把信号发送给哪个具体的线程。因此,如果我们在每个子线程中都单独设置信号掩码,就很容易导致逻辑错误。此外,所有线程共享信号处理函数。也就是说,当我们在一个线程中设置了某个信号的信号处理函数后,它将覆盖其他线程为同一个信号设置的信号处理函数。这两点都说明,我们应该定义一个专门的线程来处理所有的信号。这可以通过如下两个步骤来实现:
在主线程创建出其他子线程之前就调用pthread_sigmask来设置好信号掩码,所有新创建的子线程都将自动继承这个信号掩码。这样做之后,实际上所有线程都不会响应被屏蔽的信号了。
在某个线程中调用如下函数来等待信号并处理之:
1 |
|
- Title: 第十四章 多线程编程
- Author: Huan Lee
- Created at : 2023-08-20 08:08:14
- Updated at : 2024-02-26 04:53:15
- Link: https://www.mirthfullee.com/2023/08/20/notion-第十四章 多线程编程-f2c2889d/
- License: This work is licensed under CC BY-NC-SA 4.0.